@@ -0,0 +1,124 @@ |
||
1 |
+=begin |
|
2 |
+Usage Example: |
|
3 |
+ |
|
4 |
+class Agents::ExampleAgent < Agent |
|
5 |
+ include LongRunnable |
|
6 |
+ |
|
7 |
+ # Optional |
|
8 |
+ # Override this method if you need to group multiple agents based on an API key, |
|
9 |
+ # or server they connect to. |
|
10 |
+ # Have a look at the TwitterStreamAgent for an example. |
|
11 |
+ def self.setup_worker; end |
|
12 |
+ |
|
13 |
+ class Worker < LongRunnable::Worker |
|
14 |
+ # Optional |
|
15 |
+ # Called after initialization of the Worker class, use this method as an initializer. |
|
16 |
+ def setup; end |
|
17 |
+ |
|
18 |
+ # Required |
|
19 |
+ # Put your agent logic in here, it must not return. If it does your agent will be restarted. |
|
20 |
+ def run; end |
|
21 |
+ |
|
22 |
+ # Optional |
|
23 |
+ # Use this method the gracefully stop your agent but make sure the run method return, or |
|
24 |
+ # terminate the thread. |
|
25 |
+ def stop; end |
|
26 |
+ end |
|
27 |
+end |
|
28 |
+=end |
|
29 |
+module LongRunnable |
|
30 |
+ extend ActiveSupport::Concern |
|
31 |
+ |
|
32 |
+ included do |base| |
|
33 |
+ AgentRunner.register(base) |
|
34 |
+ end |
|
35 |
+ |
|
36 |
+ def start_worker? |
|
37 |
+ true |
|
38 |
+ end |
|
39 |
+ |
|
40 |
+ def worker_id(config = nil) |
|
41 |
+ "#{self.class.to_s}-#{id}-#{Digest::SHA1.hexdigest((config.presence || options).to_json)}" |
|
42 |
+ end |
|
43 |
+ |
|
44 |
+ module ClassMethods |
|
45 |
+ def setup_worker |
|
46 |
+ active.map do |agent| |
|
47 |
+ next unless agent.start_worker? |
|
48 |
+ self::Worker.new(id: agent.worker_id, agent: agent) |
|
49 |
+ end.compact |
|
50 |
+ end |
|
51 |
+ end |
|
52 |
+ |
|
53 |
+ class Worker |
|
54 |
+ attr_reader :thread, :id, :agent, :config, :mutex, :scheduler |
|
55 |
+ |
|
56 |
+ def initialize(options = {}) |
|
57 |
+ @id = options[:id] |
|
58 |
+ @agent = options[:agent] |
|
59 |
+ @config = options[:config] |
|
60 |
+ end |
|
61 |
+ |
|
62 |
+ def run |
|
63 |
+ raise StandardError, 'Override LongRunnable::Worker#run in your agent Worker subclass.' |
|
64 |
+ end |
|
65 |
+ |
|
66 |
+ def run! |
|
67 |
+ @thread = Thread.new do |
|
68 |
+ begin |
|
69 |
+ run |
|
70 |
+ rescue SignalException, SystemExit |
|
71 |
+ stop! |
|
72 |
+ rescue StandardError => e |
|
73 |
+ message = "#{id} Exception #{e.message}:\n#{e.backtrace.first(10).join("\n")}" |
|
74 |
+ AgentRunner.with_connection do |
|
75 |
+ agent.error(message) |
|
76 |
+ end |
|
77 |
+ end |
|
78 |
+ end |
|
79 |
+ end |
|
80 |
+ |
|
81 |
+ def setup!(scheduler, mutex) |
|
82 |
+ @scheduler = scheduler |
|
83 |
+ @mutex = mutex |
|
84 |
+ setup if respond_to?(:setup) |
|
85 |
+ end |
|
86 |
+ |
|
87 |
+ def stop! |
|
88 |
+ @scheduler.jobs(tag: id).each(&:unschedule) |
|
89 |
+ |
|
90 |
+ if respond_to?(:stop) |
|
91 |
+ stop |
|
92 |
+ else |
|
93 |
+ thread.terminate |
|
94 |
+ end |
|
95 |
+ end |
|
96 |
+ |
|
97 |
+ def restart! |
|
98 |
+ stop! |
|
99 |
+ setup!(scheduler, mutex) |
|
100 |
+ run! |
|
101 |
+ end |
|
102 |
+ |
|
103 |
+ def every(*args, &blk) |
|
104 |
+ schedule(:every, args, &blk) |
|
105 |
+ end |
|
106 |
+ |
|
107 |
+ def cron(*args, &blk) |
|
108 |
+ schedule(:cron, args, &blk) |
|
109 |
+ end |
|
110 |
+ |
|
111 |
+ def schedule_in(*args, &blk) |
|
112 |
+ schedule(:schedule_in, args, &blk) |
|
113 |
+ end |
|
114 |
+ |
|
115 |
+ def boolify(value) |
|
116 |
+ agent.send(:boolify, value) |
|
117 |
+ end |
|
118 |
+ |
|
119 |
+ private |
|
120 |
+ def schedule(method, args, &blk) |
|
121 |
+ @scheduler.send(method, *args, tag: id, &blk) |
|
122 |
+ end |
|
123 |
+ end |
|
124 |
+end |
@@ -1,7 +1,9 @@ |
||
1 | 1 |
module Agents |
2 | 2 |
class JabberAgent < Agent |
3 |
+ include LongRunnable |
|
4 |
+ include FormConfigurable |
|
5 |
+ |
|
3 | 6 |
cannot_be_scheduled! |
4 |
- cannot_create_events! |
|
5 | 7 |
|
6 | 8 |
gem_dependency_check { defined?(Jabber) } |
7 | 9 |
|
@@ -16,9 +18,22 @@ module Agents |
||
16 | 18 |
can contain any keys found in the source's payload, escaped using double curly braces. |
17 | 19 |
ex: `"News Story: {{title}}: {{url}}"` |
18 | 20 |
|
21 |
+ When `connect_to_receiver` is set to true, the JabberAgent will emit an event for every message it receives. |
|
22 |
+ |
|
19 | 23 |
Have a look at the [Wiki](https://github.com/cantino/huginn/wiki/Formatting-Events-using-Liquid) to learn more about liquid templating. |
20 | 24 |
MD |
21 | 25 |
|
26 |
+ event_description <<-MD |
|
27 |
+ `event` will be set to either `on_join`, `on_leave`, `on_message`, `on_room_message` or `on_subject` |
|
28 |
+ |
|
29 |
+ { |
|
30 |
+ "event": "on_message", |
|
31 |
+ "time": null, |
|
32 |
+ "nick": "Dominik Sander", |
|
33 |
+ "message": "Hello from huginn." |
|
34 |
+ } |
|
35 |
+ MD |
|
36 |
+ |
|
22 | 37 |
def default_options |
23 | 38 |
{ |
24 | 39 |
'jabber_server' => '127.0.0.1', |
@@ -31,6 +46,15 @@ module Agents |
||
31 | 46 |
} |
32 | 47 |
end |
33 | 48 |
|
49 |
+ form_configurable :jabber_server |
|
50 |
+ form_configurable :jabber_port |
|
51 |
+ form_configurable :jabber_sender |
|
52 |
+ form_configurable :jabber_receiver |
|
53 |
+ form_configurable :jabber_password |
|
54 |
+ form_configurable :message, type: :text |
|
55 |
+ form_configurable :connect_to_receiver, type: :boolean |
|
56 |
+ form_configurable :expected_receive_period_in_days |
|
57 |
+ |
|
34 | 58 |
def working? |
35 | 59 |
last_receive_at && last_receive_at > interpolated['expected_receive_period_in_days'].to_i.days.ago && !recent_error_logs? |
36 | 60 |
end |
@@ -50,6 +74,10 @@ module Agents |
||
50 | 74 |
client.send Jabber::Message::new(interpolated['jabber_receiver'], text).set_type(:chat) |
51 | 75 |
end |
52 | 76 |
|
77 |
+ def start_worker? |
|
78 |
+ boolify(interpolated[:connect_to_receiver]) |
|
79 |
+ end |
|
80 |
+ |
|
53 | 81 |
private |
54 | 82 |
|
55 | 83 |
def client |
@@ -66,5 +94,61 @@ module Agents |
||
66 | 94 |
def body(event) |
67 | 95 |
interpolated(event)['message'] |
68 | 96 |
end |
97 |
+ |
|
98 |
+ class Worker < LongRunnable::Worker |
|
99 |
+ IGNORE_MESSAGES_FOR=5 |
|
100 |
+ |
|
101 |
+ def setup |
|
102 |
+ require 'xmpp4r/muc/helper/simplemucclient' |
|
103 |
+ end |
|
104 |
+ |
|
105 |
+ def run |
|
106 |
+ @started_at = Time.now |
|
107 |
+ @client = client |
|
108 |
+ muc = Jabber::MUC::SimpleMUCClient.new(@client) |
|
109 |
+ |
|
110 |
+ [:on_join, :on_leave, :on_message, :on_room_message, :on_subject].each do |event| |
|
111 |
+ muc.__send__(event) do |*args| |
|
112 |
+ message_handler(event, args) |
|
113 |
+ end |
|
114 |
+ end |
|
115 |
+ |
|
116 |
+ muc.join(agent.interpolated['jabber_receiver']) |
|
117 |
+ |
|
118 |
+ sleep(1) while @client.is_connected? |
|
119 |
+ end |
|
120 |
+ |
|
121 |
+ def message_handler(event, args) |
|
122 |
+ return if Time.now - @started_at < IGNORE_MESSAGES_FOR |
|
123 |
+ |
|
124 |
+ time, nick, message = normalize_args(event, args) |
|
125 |
+ |
|
126 |
+ AgentRunner.with_connection do |
|
127 |
+ agent.create_event(payload: {event: event, time: time, nick: nick, message: message}) |
|
128 |
+ end |
|
129 |
+ end |
|
130 |
+ |
|
131 |
+ def stop |
|
132 |
+ @client.close |
|
133 |
+ @client.stop |
|
134 |
+ thread.terminate |
|
135 |
+ end |
|
136 |
+ |
|
137 |
+ def client |
|
138 |
+ agent.send(:client) |
|
139 |
+ end |
|
140 |
+ |
|
141 |
+ private |
|
142 |
+ def normalize_args(event, args) |
|
143 |
+ case event |
|
144 |
+ when :on_join, :on_leave |
|
145 |
+ [args[0], args[1]] |
|
146 |
+ when :on_message, :on_subject |
|
147 |
+ args |
|
148 |
+ when :on_room_message |
|
149 |
+ [args[0], nil, args[1]] |
|
150 |
+ end |
|
151 |
+ end |
|
152 |
+ end |
|
69 | 153 |
end |
70 | 154 |
end |
@@ -1,6 +1,7 @@ |
||
1 | 1 |
module Agents |
2 | 2 |
class TwitterStreamAgent < Agent |
3 | 3 |
include TwitterConcern |
4 |
+ include LongRunnable |
|
4 | 5 |
|
5 | 6 |
cannot_receive_events! |
6 | 7 |
|
@@ -122,5 +123,125 @@ module Agents |
||
122 | 123 |
end |
123 | 124 |
end |
124 | 125 |
end |
126 |
+ |
|
127 |
+ def self.setup_worker |
|
128 |
+ if Agents::TwitterStreamAgent.dependencies_missing? |
|
129 |
+ STDERR.puts Agents::TwitterStreamAgent.twitter_dependencies_missing |
|
130 |
+ STDERR.flush |
|
131 |
+ return false |
|
132 |
+ end |
|
133 |
+ |
|
134 |
+ Agents::TwitterStreamAgent.active.group_by { |agent| agent.twitter_oauth_token }.map do |oauth_token, agents| |
|
135 |
+ filter_to_agent_map = agents.map { |agent| agent.options[:filters] }.flatten.uniq.compact.map(&:strip).inject({}) { |m, f| m[f] = []; m } |
|
136 |
+ |
|
137 |
+ agents.each do |agent| |
|
138 |
+ agent.options[:filters].flatten.uniq.compact.map(&:strip).each do |filter| |
|
139 |
+ filter_to_agent_map[filter] << agent |
|
140 |
+ end |
|
141 |
+ end |
|
142 |
+ |
|
143 |
+ config_hash = filter_to_agent_map.map { |k, v| [k, v.map(&:id)] } |
|
144 |
+ config_hash.push(oauth_token) |
|
145 |
+ |
|
146 |
+ Worker.new(id: agents.first.worker_id(config_hash), |
|
147 |
+ config: {filter_to_agent_map: filter_to_agent_map}, |
|
148 |
+ agent: agents.first) |
|
149 |
+ end |
|
150 |
+ end |
|
151 |
+ |
|
152 |
+ class Worker < LongRunnable::Worker |
|
153 |
+ RELOAD_TIMEOUT = 60.minutes |
|
154 |
+ DUPLICATE_DETECTION_LENGTH = 1000 |
|
155 |
+ SEPARATOR = /[^\w_\-]+/ |
|
156 |
+ |
|
157 |
+ def setup |
|
158 |
+ require 'twitter/json_stream' |
|
159 |
+ @filter_to_agent_map = @config[:filter_to_agent_map] |
|
160 |
+ |
|
161 |
+ schedule_in RELOAD_TIMEOUT do |
|
162 |
+ puts "--> Restarting TwitterStream #{id}" |
|
163 |
+ restart! |
|
164 |
+ end |
|
165 |
+ end |
|
166 |
+ |
|
167 |
+ def run |
|
168 |
+ @recent_tweets = [] |
|
169 |
+ EventMachine.run do |
|
170 |
+ stream!(@filter_to_agent_map.keys, @agent) do |status| |
|
171 |
+ handle_status(status) |
|
172 |
+ end |
|
173 |
+ end |
|
174 |
+ Thread.stop |
|
175 |
+ end |
|
176 |
+ |
|
177 |
+ def stop |
|
178 |
+ EventMachine.stop_event_loop if EventMachine.reactor_running? |
|
179 |
+ thread.terminate |
|
180 |
+ end |
|
181 |
+ |
|
182 |
+ private |
|
183 |
+ def stream!(filters, agent, &block) |
|
184 |
+ filters = filters.map(&:downcase).uniq |
|
185 |
+ |
|
186 |
+ stream = Twitter::JSONStream.connect( |
|
187 |
+ :path => "/1/statuses/#{(filters && filters.length > 0) ? 'filter' : 'sample'}.json#{"?track=#{filters.map {|f| CGI::escape(f) }.join(",")}" if filters && filters.length > 0}", |
|
188 |
+ :ssl => true, |
|
189 |
+ :oauth => { |
|
190 |
+ :consumer_key => agent.twitter_consumer_key, |
|
191 |
+ :consumer_secret => agent.twitter_consumer_secret, |
|
192 |
+ :access_key => agent.twitter_oauth_token, |
|
193 |
+ :access_secret => agent.twitter_oauth_token_secret |
|
194 |
+ } |
|
195 |
+ ) |
|
196 |
+ |
|
197 |
+ stream.each_item do |status| |
|
198 |
+ block.call(status) |
|
199 |
+ end |
|
200 |
+ |
|
201 |
+ stream.on_error do |message| |
|
202 |
+ STDERR.puts " --> Twitter error: #{message} <--" |
|
203 |
+ end |
|
204 |
+ |
|
205 |
+ stream.on_no_data do |message| |
|
206 |
+ STDERR.puts " --> Got no data for awhile; trying to reconnect." |
|
207 |
+ restart! |
|
208 |
+ end |
|
209 |
+ |
|
210 |
+ stream.on_max_reconnects do |timeout, retries| |
|
211 |
+ STDERR.puts " --> Oops, tried too many times! <--" |
|
212 |
+ sleep 60 |
|
213 |
+ restart! |
|
214 |
+ end |
|
215 |
+ end |
|
216 |
+ |
|
217 |
+ def handle_status(status) |
|
218 |
+ status = JSON.parse(status) if status.is_a?(String) |
|
219 |
+ return unless status |
|
220 |
+ return if status.has_key?('delete') |
|
221 |
+ return unless status['text'] |
|
222 |
+ status['text'] = status['text'].gsub(/</, "<").gsub(/>/, ">").gsub(/[\t\n\r]/, ' ') |
|
223 |
+ |
|
224 |
+ if status["retweeted_status"].present? && status["retweeted_status"].is_a?(Hash) |
|
225 |
+ puts "Skipping retweet: #{status["text"]}" |
|
226 |
+ return |
|
227 |
+ elsif @recent_tweets.include?(status["id_str"]) |
|
228 |
+ puts "Skipping duplicate tweet: #{status["text"]}" |
|
229 |
+ return |
|
230 |
+ end |
|
231 |
+ |
|
232 |
+ @recent_tweets << status["id_str"] |
|
233 |
+ @recent_tweets.shift if @recent_tweets.length > DUPLICATE_DETECTION_LENGTH |
|
234 |
+ puts status["text"] |
|
235 |
+ @filter_to_agent_map.keys.each do |filter| |
|
236 |
+ next unless (filter.downcase.split(SEPARATOR) - status["text"].downcase.split(SEPARATOR)).reject(&:empty?) == [] # Hacky McHackerson |
|
237 |
+ @filter_to_agent_map[filter].each do |agent| |
|
238 |
+ puts " -> #{agent.name}" |
|
239 |
+ AgentRunner.with_connection do |
|
240 |
+ agent.process_tweet(filter, status) |
|
241 |
+ end |
|
242 |
+ end |
|
243 |
+ end |
|
244 |
+ end |
|
245 |
+ end |
|
125 | 246 |
end |
126 | 247 |
end |
@@ -0,0 +1,9 @@ |
||
1 |
+#!/usr/bin/env ruby |
|
2 |
+ |
|
3 |
+# This process is used to maintain Huginn's upkeep behavior, automatically running scheduled Agents and |
|
4 |
+# periodically propagating and expiring Events. It also running TwitterStreamAgents and Agents that support long running |
|
5 |
+# background jobs. |
|
6 |
+ |
|
7 |
+require_relative './pre_runner_boot' |
|
8 |
+ |
|
9 |
+AgentRunner.new(except: DelayedJobWorker).run |
@@ -0,0 +1,13 @@ |
||
1 |
+unless defined?(Rails) |
|
2 |
+ puts |
|
3 |
+ puts "Please run me with rails runner, for example:" |
|
4 |
+ puts " RAILS_ENV=production bundle exec rails runner bin/#{File.basename($0)}" |
|
5 |
+ puts |
|
6 |
+ exit 1 |
|
7 |
+end |
|
8 |
+ |
|
9 |
+Rails.configuration.cache_classes = true |
|
10 |
+ |
|
11 |
+Dotenv.load if ENV['APP_SECRET_TOKEN'].blank? |
|
12 |
+ |
|
13 |
+require 'agent_runner' |
@@ -3,13 +3,6 @@ |
||
3 | 3 |
# This process is used to maintain Huginn's upkeep behavior, automatically running scheduled Agents and |
4 | 4 |
# periodically propagating and expiring Events. It's typically run via foreman and the included Procfile. |
5 | 5 |
|
6 |
-unless defined?(Rails) |
|
7 |
- puts |
|
8 |
- puts "Please run me with rails runner, for example:" |
|
9 |
- puts " RAILS_ENV=production bundle exec rails runner bin/schedule.rb" |
|
10 |
- puts |
|
11 |
- exit 1 |
|
12 |
-end |
|
6 |
+require_relative './pre_runner_boot' |
|
13 | 7 |
|
14 |
-scheduler = HuginnScheduler.new(frequency: ENV['SCHEDULER_FREQUENCY'].presence || 0.3) |
|
15 |
-scheduler.run! |
|
8 |
+AgentRunner.new(only: HuginnScheduler).run |
@@ -1,65 +1,13 @@ |
||
1 |
-require 'thread' |
|
2 |
-require 'huginn_scheduler' |
|
3 |
-require 'twitter_stream' |
|
1 |
+#!/usr/bin/env ruby |
|
4 | 2 |
|
5 |
-Rails.configuration.cache_classes = true |
|
3 |
+require_relative './pre_runner_boot' |
|
6 | 4 |
|
7 |
-STDOUT.sync = true |
|
8 |
-STDERR.sync = true |
|
9 |
- |
|
10 |
-def stop |
|
11 |
- puts 'Exiting...' |
|
12 |
- @scheduler.stop |
|
13 |
- @dj.stop |
|
14 |
- @stream.stop |
|
15 |
-end |
|
16 |
- |
|
17 |
-def safely(&block) |
|
18 |
- begin |
|
19 |
- yield block |
|
20 |
- rescue StandardError => e |
|
21 |
- STDERR.puts "\nException #{e.message}:\n#{e.backtrace.join("\n")}\n\n" |
|
22 |
- STDERR.puts "Terminating myself ..." |
|
23 |
- STDERR.flush |
|
24 |
- stop |
|
25 |
- end |
|
26 |
-end |
|
27 |
- |
|
28 |
-threads = [] |
|
29 |
-threads << Thread.new do |
|
30 |
- safely do |
|
31 |
- @stream = TwitterStream.new |
|
32 |
- @stream.run |
|
33 |
- puts "Twitter stream stopped ..." |
|
34 |
- end |
|
35 |
-end |
|
36 |
- |
|
37 |
-threads << Thread.new do |
|
38 |
- safely do |
|
39 |
- @scheduler = HuginnScheduler.new(frequency: ENV['SCHEDULER_FREQUENCY'].presence || 0.3) |
|
40 |
- @scheduler.run! |
|
41 |
- puts "Scheduler stopped ..." |
|
42 |
- end |
|
43 |
-end |
|
44 |
- |
|
45 |
-threads << Thread.new do |
|
46 |
- safely do |
|
47 |
- require 'delayed/command' |
|
48 |
- @dj = Delayed::Worker.new |
|
49 |
- @dj.start |
|
50 |
- puts "Delayed job stopped ..." |
|
51 |
- end |
|
52 |
-end |
|
5 |
+agent_runner = AgentRunner.new |
|
53 | 6 |
|
54 | 7 |
# We need to wait a bit to let delayed_job set it's traps so we can override them |
55 |
-sleep 0.5 |
|
56 |
- |
|
57 |
-trap('TERM') do |
|
58 |
- stop |
|
59 |
-end |
|
60 |
- |
|
61 |
-trap('INT') do |
|
62 |
- stop |
|
8 |
+Thread.new do |
|
9 |
+ sleep 5 |
|
10 |
+ agent_runner.set_traps |
|
63 | 11 |
end |
64 | 12 |
|
65 |
-threads.collect { |t| t.join } |
|
13 |
+agent_runner.run |
@@ -4,12 +4,6 @@ |
||
4 | 4 |
# new or changed TwitterStreamAgents and starts to follow the stream for them. It is typically run by foreman via |
5 | 5 |
# the included Procfile. |
6 | 6 |
|
7 |
-unless defined?(Rails) |
|
8 |
- puts |
|
9 |
- puts "Please run me with rails runner, for example:" |
|
10 |
- puts " RAILS_ENV=production bundle exec rails runner bin/twitter_stream.rb" |
|
11 |
- puts |
|
12 |
- exit 1 |
|
13 |
-end |
|
7 |
+require_relative './pre_runner_boot' |
|
14 | 8 |
|
15 |
-TwitterStream.new.run |
|
9 |
+AgentRunner.new(only: Agents::TwitterStreamAgent).run |
@@ -0,0 +1,121 @@ |
||
1 |
+require 'cgi' |
|
2 |
+require 'json' |
|
3 |
+require 'rufus-scheduler' |
|
4 |
+require 'pp' |
|
5 |
+require 'twitter' |
|
6 |
+ |
|
7 |
+class AgentRunner |
|
8 |
+ @@agents = [] |
|
9 |
+ |
|
10 |
+ def initialize(options = {}) |
|
11 |
+ @workers = {} |
|
12 |
+ @signal_queue = [] |
|
13 |
+ @options = options |
|
14 |
+ @options[:only] = [@options[:only]].flatten if @options[:only] |
|
15 |
+ @options[:except] = [@options[:except]].flatten if @options[:except] |
|
16 |
+ @mutex = Mutex.new |
|
17 |
+ @scheduler = Rufus::Scheduler.new(frequency: ENV['SCHEDULER_FREQUENCY'].presence || 0.3) |
|
18 |
+ |
|
19 |
+ @scheduler.every 5 do |
|
20 |
+ restart_dead_workers if @running |
|
21 |
+ end |
|
22 |
+ |
|
23 |
+ @scheduler.every 60 do |
|
24 |
+ run_workers if @running |
|
25 |
+ end |
|
26 |
+ |
|
27 |
+ set_traps |
|
28 |
+ end |
|
29 |
+ |
|
30 |
+ def stop |
|
31 |
+ puts "Stopping AgentRunner..." |
|
32 |
+ @running = false |
|
33 |
+ @workers.each_pair do |_, w| w.stop! end |
|
34 |
+ @scheduler.stop |
|
35 |
+ end |
|
36 |
+ |
|
37 |
+ def run |
|
38 |
+ @running = true |
|
39 |
+ run_workers |
|
40 |
+ |
|
41 |
+ while @running |
|
42 |
+ if signal = @signal_queue.shift |
|
43 |
+ handle_signal(signal) |
|
44 |
+ end |
|
45 |
+ sleep 0.25 |
|
46 |
+ end |
|
47 |
+ @scheduler.join |
|
48 |
+ end |
|
49 |
+ |
|
50 |
+ def set_traps |
|
51 |
+ %w(INT TERM QUIT).each do |signal| |
|
52 |
+ Signal.trap(signal) { @signal_queue << signal } |
|
53 |
+ end |
|
54 |
+ end |
|
55 |
+ |
|
56 |
+ def self.register(agent) |
|
57 |
+ @@agents << agent unless @@agents.include?(agent) |
|
58 |
+ end |
|
59 |
+ |
|
60 |
+ def self.with_connection |
|
61 |
+ ActiveRecord::Base.connection_pool.with_connection do |
|
62 |
+ yield |
|
63 |
+ end |
|
64 |
+ end |
|
65 |
+ |
|
66 |
+ private |
|
67 |
+ def run_workers |
|
68 |
+ workers = load_workers |
|
69 |
+ new_worker_ids = workers.keys |
|
70 |
+ current_worker_ids = @workers.keys |
|
71 |
+ |
|
72 |
+ (current_worker_ids - new_worker_ids).each do |outdated_worker_id| |
|
73 |
+ puts "Killing #{outdated_worker_id}" |
|
74 |
+ @workers[outdated_worker_id].stop! |
|
75 |
+ @workers.delete(outdated_worker_id) |
|
76 |
+ end |
|
77 |
+ |
|
78 |
+ (new_worker_ids - current_worker_ids).each do |new_worker_id| |
|
79 |
+ puts "Starting #{new_worker_id}" |
|
80 |
+ @workers[new_worker_id] = workers[new_worker_id] |
|
81 |
+ @workers[new_worker_id].setup!(@scheduler, @mutex) |
|
82 |
+ @workers[new_worker_id].run! |
|
83 |
+ end |
|
84 |
+ end |
|
85 |
+ |
|
86 |
+ def load_workers |
|
87 |
+ workers = {} |
|
88 |
+ @@agents.each do |klass| |
|
89 |
+ next if @options[:only] && !@options[:only].include?(klass) |
|
90 |
+ next if @options[:except] && @options[:except].include?(klass) |
|
91 |
+ |
|
92 |
+ AgentRunner.with_connection do |
|
93 |
+ (klass.setup_worker || []) |
|
94 |
+ end.each do |agent_worker| |
|
95 |
+ workers[agent_worker.id] = agent_worker |
|
96 |
+ end |
|
97 |
+ end |
|
98 |
+ workers |
|
99 |
+ end |
|
100 |
+ |
|
101 |
+ def restart_dead_workers |
|
102 |
+ @workers.each_pair do |id, worker| |
|
103 |
+ if worker.thread && !worker.thread.alive? |
|
104 |
+ puts "Restarting #{id.to_s}" |
|
105 |
+ @workers[id].run! |
|
106 |
+ end |
|
107 |
+ end |
|
108 |
+ end |
|
109 |
+ |
|
110 |
+ def handle_signal(signal) |
|
111 |
+ case signal |
|
112 |
+ when 'INT', 'TERM', 'QUIT' |
|
113 |
+ stop |
|
114 |
+ end |
|
115 |
+ end |
|
116 |
+end |
|
117 |
+ |
|
118 |
+require 'agents/twitter_stream_agent' |
|
119 |
+require 'agents/jabber_agent' |
|
120 |
+require 'huginn_scheduler' |
|
121 |
+require 'delayed_job_worker' |
@@ -0,0 +1,16 @@ |
||
1 |
+class DelayedJobWorker < LongRunnable::Worker |
|
2 |
+ include LongRunnable |
|
3 |
+ |
|
4 |
+ def run |
|
5 |
+ @dj = Delayed::Worker.new |
|
6 |
+ @dj.start |
|
7 |
+ end |
|
8 |
+ |
|
9 |
+ def stop |
|
10 |
+ @dj.stop |
|
11 |
+ end |
|
12 |
+ |
|
13 |
+ def self.setup_worker |
|
14 |
+ [new(id: self.to_s)] |
|
15 |
+ end |
|
16 |
+end |
@@ -92,58 +92,56 @@ class Rufus::Scheduler |
||
92 | 92 |
end |
93 | 93 |
end |
94 | 94 |
|
95 |
-class HuginnScheduler |
|
96 |
- FAILED_JOBS_TO_KEEP = 100 |
|
97 |
- attr_accessor :mutex |
|
98 |
- |
|
99 |
- def initialize(options = {}) |
|
100 |
- @rufus_scheduler = Rufus::Scheduler.new(options) |
|
101 |
- self.mutex = Mutex.new |
|
102 |
- end |
|
95 |
+class HuginnScheduler < LongRunnable::Worker |
|
96 |
+ include LongRunnable |
|
103 | 97 |
|
104 |
- def stop |
|
105 |
- @rufus_scheduler.stop |
|
106 |
- end |
|
98 |
+ FAILED_JOBS_TO_KEEP = 100 |
|
107 | 99 |
|
108 |
- def run! |
|
100 |
+ def setup |
|
109 | 101 |
tzinfo_friendly_timezone = ActiveSupport::TimeZone::MAPPING[ENV['TIMEZONE'].presence || "Pacific Time (US & Canada)"] |
110 | 102 |
|
111 | 103 |
# Schedule event propagation. |
112 |
- @rufus_scheduler.every '1m' do |
|
104 |
+ every '1m' do |
|
113 | 105 |
propagate! |
114 | 106 |
end |
115 | 107 |
|
116 | 108 |
# Schedule event cleanup. |
117 |
- @rufus_scheduler.every ENV['EVENT_EXPIRATION_CHECK'].presence || '6h' do |
|
109 |
+ every ENV['EVENT_EXPIRATION_CHECK'].presence || '6h' do |
|
118 | 110 |
cleanup_expired_events! |
119 | 111 |
end |
120 | 112 |
|
121 | 113 |
# Schedule failed job cleanup. |
122 |
- @rufus_scheduler.every '1h' do |
|
114 |
+ every '1h' do |
|
123 | 115 |
cleanup_failed_jobs! |
124 | 116 |
end |
125 | 117 |
|
126 | 118 |
# Schedule repeating events. |
127 | 119 |
%w[1m 2m 5m 10m 30m 1h 2h 5h 12h 1d 2d 7d].each do |schedule| |
128 |
- @rufus_scheduler.every schedule do |
|
120 |
+ every schedule do |
|
129 | 121 |
run_schedule "every_#{schedule}" |
130 | 122 |
end |
131 | 123 |
end |
132 | 124 |
|
133 | 125 |
# Schedule events for specific times. |
134 | 126 |
24.times do |hour| |
135 |
- @rufus_scheduler.cron "0 #{hour} * * * " + tzinfo_friendly_timezone do |
|
127 |
+ cron "0 #{hour} * * * " + tzinfo_friendly_timezone do |
|
136 | 128 |
run_schedule hour_to_schedule_name(hour) |
137 | 129 |
end |
138 | 130 |
end |
139 | 131 |
|
140 | 132 |
# Schedule Scheduler Agents |
141 | 133 |
|
142 |
- @rufus_scheduler.every '1m' do |
|
143 |
- @rufus_scheduler.schedule_scheduler_agents |
|
134 |
+ every '1m' do |
|
135 |
+ @scheduler.schedule_scheduler_agents |
|
144 | 136 |
end |
137 |
+ end |
|
138 |
+ |
|
139 |
+ def run |
|
140 |
+ @scheduler.join |
|
141 |
+ end |
|
145 | 142 |
|
146 |
- @rufus_scheduler.join |
|
143 |
+ def self.setup_worker |
|
144 |
+ [new(id: self.to_s)] |
|
147 | 145 |
end |
148 | 146 |
|
149 | 147 |
private |
@@ -187,8 +185,8 @@ class HuginnScheduler |
||
187 | 185 |
end |
188 | 186 |
|
189 | 187 |
def with_mutex |
190 |
- ActiveRecord::Base.connection_pool.with_connection do |
|
191 |
- mutex.synchronize do |
|
188 |
+ mutex.synchronize do |
|
189 |
+ ActiveRecord::Base.connection_pool.with_connection do |
|
192 | 190 |
yield |
193 | 191 |
end |
194 | 192 |
end |
@@ -1,134 +0,0 @@ |
||
1 |
-require 'cgi' |
|
2 |
-require 'json' |
|
3 |
-require 'em-http-request' |
|
4 |
-require 'pp' |
|
5 |
- |
|
6 |
-class TwitterStream |
|
7 |
- def initialize |
|
8 |
- @running = true |
|
9 |
- end |
|
10 |
- |
|
11 |
- def stop |
|
12 |
- @running = false |
|
13 |
- end |
|
14 |
- |
|
15 |
- def stream!(filters, agent, &block) |
|
16 |
- filters = filters.map(&:downcase).uniq |
|
17 |
- |
|
18 |
- stream = Twitter::JSONStream.connect( |
|
19 |
- :path => "/1/statuses/#{(filters && filters.length > 0) ? 'filter' : 'sample'}.json#{"?track=#{filters.map {|f| CGI::escape(f) }.join(",")}" if filters && filters.length > 0}", |
|
20 |
- :ssl => true, |
|
21 |
- :oauth => { |
|
22 |
- :consumer_key => agent.twitter_consumer_key, |
|
23 |
- :consumer_secret => agent.twitter_consumer_secret, |
|
24 |
- :access_key => agent.twitter_oauth_token, |
|
25 |
- :access_secret => agent.twitter_oauth_token_secret |
|
26 |
- } |
|
27 |
- ) |
|
28 |
- |
|
29 |
- stream.each_item do |status| |
|
30 |
- status = JSON.parse(status) if status.is_a?(String) |
|
31 |
- next unless status |
|
32 |
- next if status.has_key?('delete') |
|
33 |
- next unless status['text'] |
|
34 |
- status['text'] = status['text'].gsub(/</, "<").gsub(/>/, ">").gsub(/[\t\n\r]/, ' ') |
|
35 |
- block.call(status) |
|
36 |
- end |
|
37 |
- |
|
38 |
- stream.on_error do |message| |
|
39 |
- STDERR.puts " --> Twitter error: #{message} <--" |
|
40 |
- end |
|
41 |
- |
|
42 |
- stream.on_no_data do |message| |
|
43 |
- STDERR.puts " --> Got no data for awhile; trying to reconnect." |
|
44 |
- EventMachine::stop_event_loop |
|
45 |
- end |
|
46 |
- |
|
47 |
- stream.on_max_reconnects do |timeout, retries| |
|
48 |
- STDERR.puts " --> Oops, tried too many times! <--" |
|
49 |
- EventMachine::stop_event_loop |
|
50 |
- end |
|
51 |
- end |
|
52 |
- |
|
53 |
- def load_and_run(agents) |
|
54 |
- agents.group_by { |agent| agent.twitter_oauth_token }.each do |oauth_token, agents| |
|
55 |
- filter_to_agent_map = agents.map { |agent| agent.options[:filters] }.flatten.uniq.compact.map(&:strip).inject({}) { |m, f| m[f] = []; m } |
|
56 |
- |
|
57 |
- agents.each do |agent| |
|
58 |
- agent.options[:filters].flatten.uniq.compact.map(&:strip).each do |filter| |
|
59 |
- filter_to_agent_map[filter] << agent |
|
60 |
- end |
|
61 |
- end |
|
62 |
- |
|
63 |
- recent_tweets = [] |
|
64 |
- |
|
65 |
- stream!(filter_to_agent_map.keys, agents.first) do |status| |
|
66 |
- if status["retweeted_status"].present? && status["retweeted_status"].is_a?(Hash) |
|
67 |
- puts "Skipping retweet: #{status["text"]}" |
|
68 |
- elsif recent_tweets.include?(status["id_str"]) |
|
69 |
- puts "Skipping duplicate tweet: #{status["text"]}" |
|
70 |
- else |
|
71 |
- recent_tweets << status["id_str"] |
|
72 |
- recent_tweets.shift if recent_tweets.length > DUPLICATE_DETECTION_LENGTH |
|
73 |
- puts status["text"] |
|
74 |
- filter_to_agent_map.keys.each do |filter| |
|
75 |
- if (filter.downcase.split(SEPARATOR) - status["text"].downcase.split(SEPARATOR)).reject(&:empty?) == [] # Hacky McHackerson |
|
76 |
- filter_to_agent_map[filter].each do |agent| |
|
77 |
- puts " -> #{agent.name}" |
|
78 |
- agent.process_tweet(filter, status) |
|
79 |
- end |
|
80 |
- end |
|
81 |
- end |
|
82 |
- end |
|
83 |
- end |
|
84 |
- end |
|
85 |
- end |
|
86 |
- |
|
87 |
- RELOAD_TIMEOUT = 10.minutes |
|
88 |
- DUPLICATE_DETECTION_LENGTH = 1000 |
|
89 |
- SEPARATOR = /[^\w_\-]+/ |
|
90 |
- |
|
91 |
- def run |
|
92 |
- if Agents::TwitterStreamAgent.dependencies_missing? |
|
93 |
- STDERR.puts Agents::TwitterStreamAgent.twitter_dependencies_missing |
|
94 |
- STDERR.flush |
|
95 |
- return |
|
96 |
- end |
|
97 |
- |
|
98 |
- require 'twitter/json_stream' |
|
99 |
- |
|
100 |
- while @running |
|
101 |
- begin |
|
102 |
- agents = Agents::TwitterStreamAgent.active.all |
|
103 |
- |
|
104 |
- EventMachine::run do |
|
105 |
- EventMachine.add_periodic_timer(1) { |
|
106 |
- EventMachine::stop_event_loop if !@running |
|
107 |
- } |
|
108 |
- |
|
109 |
- EventMachine.add_periodic_timer(RELOAD_TIMEOUT) { |
|
110 |
- puts "Reloading EventMachine and all Agents..." |
|
111 |
- EventMachine::stop_event_loop |
|
112 |
- } |
|
113 |
- |
|
114 |
- if agents.length == 0 |
|
115 |
- puts "No agents found. Will look again in a minute." |
|
116 |
- EventMachine.add_timer(60) { |
|
117 |
- EventMachine::stop_event_loop |
|
118 |
- } |
|
119 |
- else |
|
120 |
- puts "Found #{agents.length} agent(s). Loading them now..." |
|
121 |
- load_and_run agents |
|
122 |
- end |
|
123 |
- end |
|
124 |
- rescue SignalException, SystemExit |
|
125 |
- @running = false |
|
126 |
- EventMachine::stop_event_loop if EventMachine.reactor_running? |
|
127 |
- rescue StandardError => e |
|
128 |
- STDERR.puts "\nException #{e.message}:\n#{e.backtrace.join("\n")}\n\n" |
|
129 |
- STDERR.puts "Waiting for a couple of minutes..." |
|
130 |
- sleep 120 |
|
131 |
- end |
|
132 |
- end |
|
133 |
- end |
|
134 |
-end |
@@ -0,0 +1,114 @@ |
||
1 |
+require 'spec_helper' |
|
2 |
+ |
|
3 |
+describe LongRunnable do |
|
4 |
+ class LongRunnableAgent < Agent |
|
5 |
+ include LongRunnable |
|
6 |
+ |
|
7 |
+ def default_options |
|
8 |
+ {test: 'test'} |
|
9 |
+ end |
|
10 |
+ end |
|
11 |
+ |
|
12 |
+ before(:all) do |
|
13 |
+ @agent = LongRunnableAgent.new |
|
14 |
+ end |
|
15 |
+ |
|
16 |
+ it "start_worker? defaults to true" do |
|
17 |
+ expect(@agent.start_worker?).to be_truthy |
|
18 |
+ end |
|
19 |
+ |
|
20 |
+ it "should build the worker_id" do |
|
21 |
+ expect(@agent.worker_id).to eq('LongRunnableAgent--bf21a9e8fbc5a3846fb05b4fa0859e0917b2202f') |
|
22 |
+ end |
|
23 |
+ |
|
24 |
+ context "#setup_worker" do |
|
25 |
+ it "returns active agent workers" do |
|
26 |
+ mock(LongRunnableAgent).active { [@agent] } |
|
27 |
+ workers = LongRunnableAgent.setup_worker |
|
28 |
+ expect(workers.length).to eq(1) |
|
29 |
+ expect(workers.first).to be_a(LongRunnableAgent::Worker) |
|
30 |
+ expect(workers.first.agent).to eq(@agent) |
|
31 |
+ end |
|
32 |
+ |
|
33 |
+ it "returns an empty array when no agent is active" do |
|
34 |
+ mock(LongRunnableAgent).active { [] } |
|
35 |
+ workers = LongRunnableAgent.setup_worker |
|
36 |
+ expect(workers.length).to eq(0) |
|
37 |
+ end |
|
38 |
+ end |
|
39 |
+ |
|
40 |
+ describe LongRunnable::Worker do |
|
41 |
+ before(:each) do |
|
42 |
+ @agent = Object.new |
|
43 |
+ @worker = LongRunnable::Worker.new(agent: @agent, id: 'test1234') |
|
44 |
+ @worker.setup!(Rufus::Scheduler.new, Mutex.new) |
|
45 |
+ end |
|
46 |
+ |
|
47 |
+ it "calls boolify of the agent" do |
|
48 |
+ mock(@agent).boolify('true') { true } |
|
49 |
+ expect(@worker.boolify('true')).to be_truthy |
|
50 |
+ end |
|
51 |
+ |
|
52 |
+ it "expects run to be overriden" do |
|
53 |
+ expect { @worker.run }.to raise_error(StandardError) |
|
54 |
+ end |
|
55 |
+ |
|
56 |
+ context "#run!" do |
|
57 |
+ it "runs the agent worker" do |
|
58 |
+ mock(@worker).run |
|
59 |
+ @worker.run!.join |
|
60 |
+ end |
|
61 |
+ |
|
62 |
+ it "stops when rescueing a SystemExit" do |
|
63 |
+ mock(@worker).run { raise SystemExit } |
|
64 |
+ mock(@worker).stop! |
|
65 |
+ @worker.run!.join |
|
66 |
+ end |
|
67 |
+ |
|
68 |
+ it "creates an agent log entry for a generic exception" do |
|
69 |
+ stub(STDERR).puts |
|
70 |
+ mock(@worker).run { raise "woups" } |
|
71 |
+ mock(@agent).error(/woups/) |
|
72 |
+ @worker.run!.join |
|
73 |
+ end |
|
74 |
+ end |
|
75 |
+ |
|
76 |
+ context "#stop!" do |
|
77 |
+ it "terminates the thread" do |
|
78 |
+ mock(@worker.thread).terminate |
|
79 |
+ @worker.stop! |
|
80 |
+ end |
|
81 |
+ |
|
82 |
+ it "gracefully stops the worker" do |
|
83 |
+ mock(@worker).stop |
|
84 |
+ @worker.stop! |
|
85 |
+ end |
|
86 |
+ end |
|
87 |
+ |
|
88 |
+ context "#restart!" do |
|
89 |
+ it "stops, setups and starts the worker" do |
|
90 |
+ mock(@worker).stop! |
|
91 |
+ mock(@worker).setup!(@worker.scheduler, @worker.mutex) |
|
92 |
+ mock(@worker).run! |
|
93 |
+ @worker.restart! |
|
94 |
+ end |
|
95 |
+ end |
|
96 |
+ |
|
97 |
+ context "scheduling" do |
|
98 |
+ it "schedules tasks once" do |
|
99 |
+ mock(@worker.scheduler).send(:schedule_in, 1.hour, tag: 'test1234') |
|
100 |
+ @worker.schedule_in 1.hour do noop; end |
|
101 |
+ end |
|
102 |
+ |
|
103 |
+ it "schedules repeating tasks" do |
|
104 |
+ mock(@worker.scheduler).send(:every, 1.hour, tag: 'test1234') |
|
105 |
+ @worker.every 1.hour do noop; end |
|
106 |
+ end |
|
107 |
+ |
|
108 |
+ it "allows the cron syntax" do |
|
109 |
+ mock(@worker.scheduler).send(:cron, '0 * * * *', tag: 'test1234') |
|
110 |
+ @worker.cron '0 * * * *' do noop; end |
|
111 |
+ end |
|
112 |
+ end |
|
113 |
+ end |
|
114 |
+end |
@@ -0,0 +1,102 @@ |
||
1 |
+require 'spec_helper' |
|
2 |
+ |
|
3 |
+describe AgentRunner do |
|
4 |
+ context "without traps" do |
|
5 |
+ before do |
|
6 |
+ stub.instance_of(Rufus::Scheduler).every |
|
7 |
+ stub.instance_of(AgentRunner).set_traps |
|
8 |
+ @agent_runner = AgentRunner.new |
|
9 |
+ end |
|
10 |
+ |
|
11 |
+ context "#run" do |
|
12 |
+ before do |
|
13 |
+ mock(@agent_runner).run_workers |
|
14 |
+ mock.instance_of(IO).puts('Stopping AgentRunner...') |
|
15 |
+ end |
|
16 |
+ |
|
17 |
+ it "runs until stop is called" do |
|
18 |
+ mock.instance_of(Rufus::Scheduler).join |
|
19 |
+ Thread.new { while @agent_runner.instance_variable_get(:@running) != false do sleep 0.1; @agent_runner.stop end } |
|
20 |
+ @agent_runner.run |
|
21 |
+ end |
|
22 |
+ |
|
23 |
+ it "handles signals" do |
|
24 |
+ @agent_runner.instance_variable_set(:@signal_queue, ['TERM']) |
|
25 |
+ @agent_runner.run |
|
26 |
+ end |
|
27 |
+ end |
|
28 |
+ |
|
29 |
+ context "#load_workers" do |
|
30 |
+ before do |
|
31 |
+ AgentRunner.class_variable_set(:@@agents, [HuginnScheduler, DelayedJobWorker]) |
|
32 |
+ end |
|
33 |
+ it "loads all workers" do |
|
34 |
+ workers = @agent_runner.send(:load_workers) |
|
35 |
+ expect(workers).to be_a(Hash) |
|
36 |
+ expect(workers.keys).to eq(['HuginnScheduler', 'DelayedJobWorker']) |
|
37 |
+ end |
|
38 |
+ |
|
39 |
+ it "loads only the workers specified in the :only option" do |
|
40 |
+ @agent_runner = AgentRunner.new(only: HuginnScheduler) |
|
41 |
+ workers = @agent_runner.send(:load_workers) |
|
42 |
+ expect(workers.keys).to eq(['HuginnScheduler']) |
|
43 |
+ end |
|
44 |
+ |
|
45 |
+ it "does not load workers specified in the :except option" do |
|
46 |
+ @agent_runner = AgentRunner.new(except: HuginnScheduler) |
|
47 |
+ workers = @agent_runner.send(:load_workers) |
|
48 |
+ expect(workers.keys).to eq(['DelayedJobWorker']) |
|
49 |
+ end |
|
50 |
+ end |
|
51 |
+ |
|
52 |
+ context "running workers" do |
|
53 |
+ before do |
|
54 |
+ AgentRunner.class_variable_set(:@@agents, [HuginnScheduler, DelayedJobWorker]) |
|
55 |
+ stub.instance_of(IO).puts |
|
56 |
+ stub.instance_of(LongRunnable::Worker).setup! |
|
57 |
+ end |
|
58 |
+ |
|
59 |
+ context "#run_workers" do |
|
60 |
+ |
|
61 |
+ it "runs all the workers" do |
|
62 |
+ mock.instance_of(HuginnScheduler).run! |
|
63 |
+ mock.instance_of(DelayedJobWorker).run! |
|
64 |
+ @agent_runner.send(:run_workers) |
|
65 |
+ end |
|
66 |
+ |
|
67 |
+ it "kills no long active workers" do |
|
68 |
+ mock.instance_of(HuginnScheduler).run! |
|
69 |
+ mock.instance_of(DelayedJobWorker).run! |
|
70 |
+ @agent_runner.send(:run_workers) |
|
71 |
+ AgentRunner.class_variable_set(:@@agents, [DelayedJobWorker]) |
|
72 |
+ mock.instance_of(HuginnScheduler).stop! |
|
73 |
+ @agent_runner.send(:run_workers) |
|
74 |
+ end |
|
75 |
+ end |
|
76 |
+ |
|
77 |
+ context "#restart_dead_workers" do |
|
78 |
+ before do |
|
79 |
+ mock.instance_of(HuginnScheduler).run! |
|
80 |
+ mock.instance_of(DelayedJobWorker).run! |
|
81 |
+ @agent_runner.send(:run_workers) |
|
82 |
+ |
|
83 |
+ end |
|
84 |
+ it "restarts dead workers" do |
|
85 |
+ stub.instance_of(HuginnScheduler).thread { OpenStruct.new(alive?: false) } |
|
86 |
+ mock.instance_of(HuginnScheduler).run! |
|
87 |
+ @agent_runner.send(:restart_dead_workers) |
|
88 |
+ end |
|
89 |
+ end |
|
90 |
+ end |
|
91 |
+ end |
|
92 |
+ |
|
93 |
+ context "#set_traps" do |
|
94 |
+ it "sets traps for INT TERM and QUIT" do |
|
95 |
+ agent_runner = AgentRunner.new |
|
96 |
+ mock(Signal).trap('INT') |
|
97 |
+ mock(Signal).trap('TERM') |
|
98 |
+ mock(Signal).trap('QUIT') |
|
99 |
+ agent_runner.set_traps |
|
100 |
+ end |
|
101 |
+ end |
|
102 |
+end |
@@ -0,0 +1,28 @@ |
||
1 |
+require 'spec_helper' |
|
2 |
+ |
|
3 |
+describe DelayedJobWorker do |
|
4 |
+ before do |
|
5 |
+ @djw = DelayedJobWorker.new |
|
6 |
+ end |
|
7 |
+ |
|
8 |
+ it "should run" do |
|
9 |
+ mock.instance_of(Delayed::Worker).start |
|
10 |
+ @djw.run |
|
11 |
+ end |
|
12 |
+ |
|
13 |
+ it "should stop" do |
|
14 |
+ mock.instance_of(Delayed::Worker).start |
|
15 |
+ mock.instance_of(Delayed::Worker).stop |
|
16 |
+ @djw.run |
|
17 |
+ @djw.stop |
|
18 |
+ end |
|
19 |
+ |
|
20 |
+ context "#setup_worker" do |
|
21 |
+ it "should return an array with an instance of itself" do |
|
22 |
+ workers = DelayedJobWorker.setup_worker |
|
23 |
+ expect(workers).to be_a(Array) |
|
24 |
+ expect(workers.first).to be_a(DelayedJobWorker) |
|
25 |
+ expect(workers.first.id).to eq('DelayedJobWorker') |
|
26 |
+ end |
|
27 |
+ end |
|
28 |
+end |
@@ -4,17 +4,16 @@ require 'huginn_scheduler' |
||
4 | 4 |
describe HuginnScheduler do |
5 | 5 |
before(:each) do |
6 | 6 |
@scheduler = HuginnScheduler.new |
7 |
+ stub(@scheduler).setup {} |
|
8 |
+ @scheduler.setup!(Rufus::Scheduler.new, Mutex.new) |
|
7 | 9 |
stub |
8 | 10 |
end |
9 | 11 |
|
10 |
- it "should stop the scheduler" do |
|
11 |
- mock.instance_of(Rufus::Scheduler).stop |
|
12 |
- @scheduler.stop |
|
13 |
- end |
|
14 |
- |
|
15 | 12 |
it "schould register the schedules with the rufus scheduler and run" do |
16 | 13 |
mock.instance_of(Rufus::Scheduler).join |
17 |
- @scheduler.run! |
|
14 |
+ scheduler = HuginnScheduler.new |
|
15 |
+ scheduler.setup!(Rufus::Scheduler.new, Mutex.new) |
|
16 |
+ scheduler.run |
|
18 | 17 |
end |
19 | 18 |
|
20 | 19 |
it "should run scheduled agents" do |
@@ -53,7 +52,7 @@ describe HuginnScheduler do |
||
53 | 52 |
end |
54 | 53 |
end |
55 | 54 |
|
56 |
- describe "cleanup_failed_jobs!" do |
|
55 |
+ describe "cleanup_failed_jobs!", focus: true do |
|
57 | 56 |
before do |
58 | 57 |
3.times do |i| |
59 | 58 |
Delayed::Job.create(failed_at: Time.now - i.minutes) |
@@ -75,6 +74,15 @@ describe HuginnScheduler do |
||
75 | 74 |
ENV['FAILED_JOBS_TO_KEEP'] = old |
76 | 75 |
end |
77 | 76 |
end |
77 |
+ |
|
78 |
+ context "#setup_worker" do |
|
79 |
+ it "should return an array with an instance of itself" do |
|
80 |
+ workers = HuginnScheduler.setup_worker |
|
81 |
+ expect(workers).to be_a(Array) |
|
82 |
+ expect(workers.first).to be_a(HuginnScheduler) |
|
83 |
+ expect(workers.first.id).to eq('HuginnScheduler') |
|
84 |
+ end |
|
85 |
+ end |
|
78 | 86 |
end |
79 | 87 |
|
80 | 88 |
describe Rufus::Scheduler do |
@@ -44,6 +44,17 @@ describe Agents::JabberAgent do |
||
44 | 44 |
end |
45 | 45 |
end |
46 | 46 |
|
47 |
+ context "#start_worker?" do |
|
48 |
+ it "starts when connect_to_receiver is truthy" do |
|
49 |
+ agent.options[:connect_to_receiver] = 'true' |
|
50 |
+ expect(agent.start_worker?).to be_truthy |
|
51 |
+ end |
|
52 |
+ |
|
53 |
+ it "does not starts when connect_to_receiver is not truthy" do |
|
54 |
+ expect(agent.start_worker?).to be_falsy |
|
55 |
+ end |
|
56 |
+ end |
|
57 |
+ |
|
47 | 58 |
describe "validation" do |
48 | 59 |
before do |
49 | 60 |
expect(agent).to be_valid |
@@ -78,4 +89,66 @@ describe Agents::JabberAgent do |
||
78 | 89 |
'Warning! Another Weather Alert! - http://www.weather.com/we-are-screwed']) |
79 | 90 |
end |
80 | 91 |
end |
92 |
+ |
|
93 |
+ describe Agents::JabberAgent::Worker do |
|
94 |
+ before(:each) do |
|
95 |
+ @worker = Agents::JabberAgent::Worker.new(agent: agent) |
|
96 |
+ @worker.setup |
|
97 |
+ stub.any_instance_of(Jabber::Client).connect |
|
98 |
+ stub.any_instance_of(Jabber::Client).auth |
|
99 |
+ end |
|
100 |
+ |
|
101 |
+ it "runs" do |
|
102 |
+ agent.options[:jabber_receiver] = 'someJID' |
|
103 |
+ mock.any_instance_of(Jabber::MUC::SimpleMUCClient).join('someJID') |
|
104 |
+ @worker.run |
|
105 |
+ end |
|
106 |
+ |
|
107 |
+ it "stops" do |
|
108 |
+ @worker.instance_variable_set(:@client, @worker.client) |
|
109 |
+ mock.any_instance_of(Jabber::Client).close |
|
110 |
+ mock.any_instance_of(Jabber::Client).stop |
|
111 |
+ mock(@worker).thread { mock!.terminate } |
|
112 |
+ @worker.stop |
|
113 |
+ end |
|
114 |
+ |
|
115 |
+ context "#message_handler" do |
|
116 |
+ it "it ignores messages for the first seconds" do |
|
117 |
+ @worker.instance_variable_set(:@started_at, Time.now) |
|
118 |
+ expect { @worker.message_handler(:on_message, [123456, 'nick', 'hello']) } |
|
119 |
+ .to change { agent.events.count }.by(0) |
|
120 |
+ end |
|
121 |
+ |
|
122 |
+ it "creates events" do |
|
123 |
+ @worker.instance_variable_set(:@started_at, Time.now - 10.seconds) |
|
124 |
+ expect { @worker.message_handler(:on_message, [123456, 'nick', 'hello']) } |
|
125 |
+ .to change { agent.events.count }.by(1) |
|
126 |
+ event = agent.events.last |
|
127 |
+ expect(event.payload).to eq({'event' => 'on_message', 'time' => 123456, 'nick' => 'nick', 'message' => 'hello'}) |
|
128 |
+ end |
|
129 |
+ end |
|
130 |
+ |
|
131 |
+ context "#normalize_args" do |
|
132 |
+ it "handles :on_join and :on_leave" do |
|
133 |
+ time, nick, message = @worker.send(:normalize_args, :on_join, [123456, 'nick']) |
|
134 |
+ expect(time).to eq(123456) |
|
135 |
+ expect(nick).to eq('nick') |
|
136 |
+ expect(message).to be_nil |
|
137 |
+ end |
|
138 |
+ |
|
139 |
+ it "handles :on_message and :on_leave" do |
|
140 |
+ time, nick, message = @worker.send(:normalize_args, :on_message, [123456, 'nick', 'hello']) |
|
141 |
+ expect(time).to eq(123456) |
|
142 |
+ expect(nick).to eq('nick') |
|
143 |
+ expect(message).to eq('hello') |
|
144 |
+ end |
|
145 |
+ |
|
146 |
+ it "handles :on_room_message" do |
|
147 |
+ time, nick, message = @worker.send(:normalize_args, :on_room_message, [123456, 'hello']) |
|
148 |
+ expect(time).to eq(123456) |
|
149 |
+ expect(nick).to be_nil |
|
150 |
+ expect(message).to eq('hello') |
|
151 |
+ end |
|
152 |
+ end |
|
153 |
+ end |
|
81 | 154 |
end |
@@ -125,4 +125,149 @@ describe Agents::TwitterStreamAgent do |
||
125 | 125 |
end |
126 | 126 |
end |
127 | 127 |
end |
128 |
+ |
|
129 |
+ context "#setup_worker" do |
|
130 |
+ it "ensures the dependencies are available" do |
|
131 |
+ mock(STDERR).puts(Agents::TwitterStreamAgent.twitter_dependencies_missing) |
|
132 |
+ mock(Agents::TwitterStreamAgent).dependencies_missing? { true } |
|
133 |
+ expect(Agents::TwitterStreamAgent.setup_worker).to eq(false) |
|
134 |
+ end |
|
135 |
+ |
|
136 |
+ it "returns now workers if no agent is active" do |
|
137 |
+ mock(Agents::TwitterStreamAgent).active { [] } |
|
138 |
+ expect(Agents::TwitterStreamAgent.setup_worker).to eq([]) |
|
139 |
+ end |
|
140 |
+ |
|
141 |
+ it "returns a worker for an active agent" do |
|
142 |
+ mock(Agents::TwitterStreamAgent).active { [@agent] } |
|
143 |
+ workers = Agents::TwitterStreamAgent.setup_worker |
|
144 |
+ expect(workers).to be_a(Array) |
|
145 |
+ expect(workers.length).to eq(1) |
|
146 |
+ expect(workers.first).to be_a(Agents::TwitterStreamAgent::Worker) |
|
147 |
+ filter_to_agent_map = workers.first.config[:filter_to_agent_map] |
|
148 |
+ expect(filter_to_agent_map.keys).to eq(['keyword1', 'keyword2']) |
|
149 |
+ expect(filter_to_agent_map.values).to eq([[@agent], [@agent]]) |
|
150 |
+ end |
|
151 |
+ |
|
152 |
+ it "correctly maps keywords to agents" do |
|
153 |
+ agent2 = @agent.dup |
|
154 |
+ agent2.id = 123455 |
|
155 |
+ agent2.options[:filters] = ['agent2'] |
|
156 |
+ mock(Agents::TwitterStreamAgent).active { [@agent, agent2] } |
|
157 |
+ |
|
158 |
+ workers = Agents::TwitterStreamAgent.setup_worker |
|
159 |
+ filter_to_agent_map = workers.first.config[:filter_to_agent_map] |
|
160 |
+ expect(filter_to_agent_map.keys).to eq(['keyword1', 'keyword2', 'agent2']) |
|
161 |
+ expect(filter_to_agent_map['keyword1']).to eq([@agent]) |
|
162 |
+ expect(filter_to_agent_map['agent2']).to eq([agent2]) |
|
163 |
+ end |
|
164 |
+ end |
|
165 |
+ |
|
166 |
+ describe Agents::TwitterStreamAgent::Worker do |
|
167 |
+ before(:each) do |
|
168 |
+ @mock_agent = mock! |
|
169 |
+ @config = {agent: @agent, config: {filter_to_agent_map: {'agent' => [@mock_agent]}}} |
|
170 |
+ @worker = Agents::TwitterStreamAgent::Worker.new(@config) |
|
171 |
+ @worker.instance_variable_set(:@recent_tweets, []) |
|
172 |
+ mock(@worker).schedule_in(Agents::TwitterStreamAgent::Worker::RELOAD_TIMEOUT) |
|
173 |
+ @worker.setup!(nil, Mutex.new) |
|
174 |
+ end |
|
175 |
+ |
|
176 |
+ context "#run" do |
|
177 |
+ it "starts the stream" do |
|
178 |
+ mock(EventMachine).run.yields |
|
179 |
+ mock(@worker).stream!(['agent'], @agent) |
|
180 |
+ mock(Thread).stop |
|
181 |
+ @worker.run |
|
182 |
+ end |
|
183 |
+ |
|
184 |
+ it "yields received tweets" do |
|
185 |
+ mock(EventMachine).run.yields |
|
186 |
+ mock(@worker).stream!(['agent'], @agent).yields('status' => 'hello') |
|
187 |
+ mock(@worker).handle_status('status' => 'hello') |
|
188 |
+ mock(Thread).stop |
|
189 |
+ @worker.run |
|
190 |
+ end |
|
191 |
+ end |
|
192 |
+ |
|
193 |
+ context "#stop" do |
|
194 |
+ it "stops the thread" do |
|
195 |
+ mock(@worker.thread).terminate |
|
196 |
+ @worker.stop |
|
197 |
+ end |
|
198 |
+ end |
|
199 |
+ |
|
200 |
+ context "stream!" do |
|
201 |
+ def stub_without(method = nil) |
|
202 |
+ stream_stub = stub! |
|
203 |
+ stream_stub.each_item if method != :each_item |
|
204 |
+ stream_stub.on_error if method != :on_error |
|
205 |
+ stream_stub.on_no_data if method != :on_no_data |
|
206 |
+ stream_stub.on_max_reconnects if method != :on_max_reconnects |
|
207 |
+ stub(Twitter::JSONStream).connect { stream_stub } |
|
208 |
+ stream_stub |
|
209 |
+ end |
|
210 |
+ |
|
211 |
+ it "initializes Twitter::JSONStream" do |
|
212 |
+ mock(Twitter::JSONStream).connect({:path=>"/1/statuses/filter.json?track=agent", |
|
213 |
+ :ssl=>true, :oauth=>{:consumer_key=>"twitteroauthkey", |
|
214 |
+ :consumer_secret=>"twitteroauthsecret", |
|
215 |
+ :access_key=>"1234token", |
|
216 |
+ :access_secret=>"56789secret"} |
|
217 |
+ }) { stub_without } |
|
218 |
+ @worker.send(:stream!, ['agent'], @agent) |
|
219 |
+ end |
|
220 |
+ |
|
221 |
+ context "callback handling" do |
|
222 |
+ it "logs error messages" do |
|
223 |
+ stub_without(:on_error).on_error.yields('woups') |
|
224 |
+ mock(STDERR).puts(" --> Twitter error: woups <--") |
|
225 |
+ @worker.send(:stream!, ['agent'], @agent) |
|
226 |
+ end |
|
227 |
+ |
|
228 |
+ it "stop when no data was received"do |
|
229 |
+ stub_without(:on_no_data).on_no_data.yields |
|
230 |
+ mock(@worker).restart! |
|
231 |
+ mock(STDERR).puts(" --> Got no data for awhile; trying to reconnect.") |
|
232 |
+ @worker.send(:stream!, ['agent'], @agent) |
|
233 |
+ end |
|
234 |
+ |
|
235 |
+ it "sleeps for 60 seconds on_max_reconnects" do |
|
236 |
+ stub_without(:on_max_reconnects).on_max_reconnects.yields |
|
237 |
+ mock(STDERR).puts(" --> Oops, tried too many times! <--") |
|
238 |
+ mock(@worker).sleep(60) |
|
239 |
+ mock(@worker).restart! |
|
240 |
+ @worker.send(:stream!, ['agent'], @agent) |
|
241 |
+ end |
|
242 |
+ |
|
243 |
+ it "yields every status received" do |
|
244 |
+ stub_without(:each_item).each_item.yields({'text' => 'hello'}) |
|
245 |
+ @worker.send(:stream!, ['agent'], @agent) do |status| |
|
246 |
+ expect(status).to eq({'text' => 'hello'}) |
|
247 |
+ end |
|
248 |
+ end |
|
249 |
+ end |
|
250 |
+ end |
|
251 |
+ |
|
252 |
+ context "#handle_status" do |
|
253 |
+ it "skips retweets" do |
|
254 |
+ mock.instance_of(IO).puts('Skipping retweet: retweet') |
|
255 |
+ @worker.send(:handle_status, {'text' => 'retweet', 'retweeted_status' => {one: true}}) |
|
256 |
+ end |
|
257 |
+ |
|
258 |
+ it "deduplicates tweets" do |
|
259 |
+ mock.instance_of(IO).puts("dup") |
|
260 |
+ @worker.send(:handle_status, {'text' => 'dup', 'id_str' => 1}) |
|
261 |
+ mock.instance_of(IO).puts("Skipping duplicate tweet: dup") |
|
262 |
+ @worker.send(:handle_status, {'text' => 'dup', 'id_str' => 1}) |
|
263 |
+ end |
|
264 |
+ |
|
265 |
+ it "calls the agent to process the tweet" do |
|
266 |
+ stub.instance_of(IO).puts |
|
267 |
+ mock(@mock_agent).name { 'mock' } |
|
268 |
+ mock(@mock_agent).process_tweet('agent', {'text' => 'agent'}) |
|
269 |
+ @worker.send(:handle_status, {'text' => 'agent'}) |
|
270 |
+ end |
|
271 |
+ end |
|
272 |
+ end |
|
128 | 273 |
end |